Day 18 最後說到,不管單機還是 Cluster,指令都是一個一個送:送出去、等回覆、再送下一個。Redis 執行一個 SET 很快,時間大多花在來回的路上。
今天看兩種把指令包在一起送的做法:Pipeline 省來回的時間,MULTI 讓一串指令中間不被插隊。MULTI 常被叫做 Redis 的事務(transaction,跟資料庫的「交易」是同一個英文字),但它跟資料庫的交易不一樣,出錯不會回滾。
EXEC 之前被別人改過就整批不做)先把 Day 18 的 Cluster 關掉,開回 Day 2 的 redis30days(在主機的終端機下):
docker compose -f docker-compose-cluster.yml down
docker compose up -d
Java 的 Program arguments 把 --spring.profiles.active=cluster 拿掉,改連回 6379。
新增一個 PipelineController,一樣寫 n 筆,一個一筆一筆送,一個用 Pipeline:
/** 一筆一筆送:送出去、等回覆,才送下一筆 */
@PostMapping("/one")
public Map<String, Object> one(@RequestParam int n) {
long start = System.currentTimeMillis();
for (int i = 1; i <= n; i++) {
redis.opsForValue().set("pipe:" + i, "1");
}
return Map.of("n", n, "ms", System.currentTimeMillis() - start);
}
/** Pipeline:n 筆先全部送出去,最後再一次收回覆 */
@PostMapping("/batch")
public Map<String, Object> batch(@RequestParam int n) {
long start = System.currentTimeMillis();
redis.executePipelined((RedisCallback<Object>) con -> {
StringRedisConnection str = (StringRedisConnection) con;
for (int i = 1; i <= n; i++) {
str.set("pipe:" + i, "1");
}
return null; // 一定要回 null,回覆 Spring 會自己收
});
return Map.of("n", n, "ms", System.currentTimeMillis() - start);
}
重新啟動 Java,各寫一萬筆(在主機的終端機下):
curl -X POST "localhost:8080/pipeline/one?n=10000"
curl -X POST "localhost:8080/pipeline/batch?n=10000"


一筆一筆送花了 1902 毫秒,Pipeline 只要 212 毫秒,快了 9 倍左右。
Redis 要做的事一樣是一萬次 SET,差在等待:一筆一筆送,每一筆都要等回覆才送下一筆;Pipeline 不等,一萬筆先全部送出去,回覆最後再一起收。之前灌資料用的 redis-cli --pipe 就是這個。
但 Pipeline 只是打包送,Redis 那邊還是一筆一筆執行,中間別人的指令照樣可以插進來。
進 redis-cli,A 轉 30 給 B(在主機的終端機下):
docker exec -it redis30days redis-cli
MSET balance:A 100 balance:B 50
MULTI
DECRBY balance:A 30
INCRBY balance:B 30
EXEC

MULTI 之後的指令不會馬上執行,只回 QUEUED(排隊中),提示也多了 (TX),代表正在排隊。EXEC 才一口氣跑完,跑的時候中間不會插進別人的指令。
把 B 的餘額改成小數,再轉一次:
SET balance:B 80.5
MULTI
DECRBY balance:A 30
INCRBY balance:B 30
EXEC
MGET balance:A balance:B

INCRBY 只能加整數,B 那筆噴錯。但 A 那筆照樣執行了,A 被扣了 30,B 沒加到,30 塊就這樣不見了。
MULTI 不會回滾,錯的那筆失敗,其他的照樣生效。
指令打錯字的話不一樣:
MULTI
DECRBY balance:A 30
INCRBYY balance:B 30
EXEC
GET balance:A

INCRBYY 排隊的時候就被擋下來,EXEC 回 EXECABORT,整批都不做,A 還是 40。
排隊的時候,Redis 只檢查指令名字跟參數個數,不會去看 key 裡面存了什麼:
INCRBYY 打錯字,排隊時就擋下來,整批不做EXEC 真的去加才發現,這時 A 已經扣了,只有 B 那筆失敗一樣在 redis-cli 裡,先盯著 A:
WATCH balance:A
GET balance:A
MULTI
DECRBY balance:A 30
先不要 EXEC,開另一個終端機,搶先扣 A 10 塊(在主機的終端機下):
docker exec redis30days redis-cli DECRBY balance:A 10
回到 redis-cli:
EXEC
GET balance:A

WATCH 之後 A 被別人改過,EXEC 回 (nil),整批不做,A 是別人扣完的 30。
所以可以先 GET 看餘額夠不夠,夠了再 MULTI 扣,中間被別人搶先改了就不做,重新讀一次再來。這種做法叫樂觀鎖:先不鎖,送出去的時候才檢查有沒有被改過。
exit 出來,新增一個 TransferController,一樣 A 轉給 B:
/** 包在 SessionCallback 裡,MULTI 到 EXEC 才會用同一條連線 */
@PostMapping
public List<Object> transfer(@RequestParam long amount) {
return redis.execute(new SessionCallback<List<Object>>() {
@Override
@SuppressWarnings("unchecked")
public List<Object> execute(RedisOperations ops) {
ops.multi();
ops.opsForValue().decrement("balance:A", amount); // 排隊,還沒執行
ops.opsForValue().increment("balance:B", amount);
return ops.exec(); // 一口氣執行,回每一筆的結果
}
});
}
/** 沒包 SessionCallback:每一行各拿各的連線 */
@PostMapping("/wrong")
public List<Object> wrong(@RequestParam long amount) {
redis.multi();
redis.opsForValue().decrement("balance:A", amount);
redis.opsForValue().increment("balance:B", amount);
return redis.exec();
}
重新啟動 Java,開另一個終端機,盯著 Redis 收到的指令(MONITOR 會印出收到的每個指令,在主機的終端機下):
docker exec redis30days redis-cli MONITOR | grep -E "MULTI|EXEC|DECRBY|INCRBY"
回到原本的終端機,餘額設回來,兩種寫法各轉一次(在主機的終端機下):
docker exec redis30days redis-cli MSET balance:A 100 balance:B 50
curl -X POST "localhost:8080/transfer?amount=30"
curl -X POST "localhost:8080/transfer/wrong?amount=30"
docker exec redis30days redis-cli MGET balance:A balance:B



/transfer 回 [70,80]。/wrong 回 500,但 MGET 一看,A 又少了 30、B 又多了 30,錢已經轉過去了。
看一下 MONITOR:

中括號裡冒號後面的數字是連線的 port,每條連線都不一樣:
/transfer:四行都是同一條連線/wrong:MULTI、DECRBY 跟 INCRBY、EXEC 各走一條。DECRBY 跟 INCRBY 那條沒下過 MULTI,直接就執行了;EXEC 那條也沒下過 MULTI,Java 的 console 噴 ERR EXEC without MULTI,所以回 500Spring 的 multi() 一定要包在 SessionCallback 裡,不然看起來有排隊,其實是一行一行直接執行。
SessionCallback,B 是小數的時候打 /transfer 一樣回 500,但 A 的 30 已經扣了。看到錯誤就直接重試,A 就會被扣兩次。相關範例程式碼可以參考 https://github.com/gary880306/redis-30days/tree/dev
MULTI 排隊的時候指令還沒執行,拿不到值。要「先看夠不夠扣、夠才扣」只能搭 WATCH,被別人改過就得重來,人一多就一直重試。明天來看 Lua 腳本,判斷跟扣款在 Redis 裡一次做完![]()